Add liquidation-guard: Kamino Lend health guard with unsigned rescue transactions - #104
Add liquidation-guard: Kamino Lend health guard with unsigned rescue transactions#104Ansh-699 wants to merge 5 commits into
Conversation
93ac56e to
813ba6d
Compare
…transactions One tool, `kamino_guard`, four actions: - `check` — tiered health warning (OK/WATCH/WARN/CRITICAL), liquidation-price forecast in both directions (each in its own asset's oracle price; LST collateral's drop line quoted at the underlying SOL level via stake rate), interest-drift attribution, and ranked remedies restoring to the WATCH boundary. - `portfolio` — the same across every obligation of a wallet. - `rescue` — base64 unsigned `repay_obligation_liquidity_v2` transaction. - `deposit` — base64 unsigned `deposit_reserve_liquidity_and_obligation_collateral_v2` transaction. Both builders compose two opt-in, default-off knobs: priority fees (compute-budget pair) and durable nonce (advance ix pinned at index 0, fail-closed 80-byte parse with an authority-must-equal-wallet gate). With both off, output is byte-identical to the captured mainnet goldens. Custody: the plugin cannot sign and cannot broadcast. No key material, one zeroed signature slot, no `sendTransaction` anywhere in `src/`. Transactions are inbound-only — repay and deposit both move funds from the user's wallet into the user's own position; withdraw/borrow/liquidate appear nowhere in `src/`. Both bans are grep-verifiable. The wallet-to-position binding is checked locally against `state.owner` rather than trusted from the API, so the inbound-only property is verifiable in this source. Everything the plugin parses comes from a remote HTTP API or a language model, so the parse boundary is where it fails closed: identifier fields must be base58 32-byte pubkeys, numeric fields must be finite and non-negative (Rust's `f64::from_str` accepts "NaN" and "-1e400", and a negative total would report a maximally unhealthy position as OK), display strings are allowlisted and length-capped before they reach model-visible output, and one unmappable row fails the whole list rather than silently removing one of the user's positions from consideration. Every numeric field of the HTTP `Date` header and every payload timestamp is range-checked, because with `overflow-checks = true` an overflow is an unrecoverable wasm trap rather than an error. Evidence: byte-for-byte reproduction of two captured mainnet transactions (repay 3oVjuGzMdAqqJy5poCzHUXqguwypgoM33JfZHFGkb5zb7gfPRtHXgFsgEG7iZP8WWerHttjYdnA8jamwgLbdDiac, deposit 5wcNDh7HcUVEipGHk2xnzMigX1LwkPBPvsMJPvukUU3mxGkFTe1WYY3PMdHnufwCHkeDnUa1gECsYccEDuUDF7np); 150 tests plus 8 ignored-by-default live-evidence tests; clippy -D warnings on native and wasm32-wasip2; four transaction shapes simulated against mainnet via simulateTransaction (curl, outside the plugin). Release artifact is 564,783 bytes for wasm32-wasip2. Dual-licensed MIT OR Apache-2.0.
813ba6d to
fff8443
Compare
Two defects the audit surfaced, both small and both in the class the release gate already caught once. The citation loop: rescue.rs pointed at the README's "Design decisions" section for the pinned classic-SPL token program, but that section covers LST pricing, close factor and grace period — never the token program. The README's own Token-2022 bullet then pointed at a "Deviations" section that does not exist anywhere in the file, so a reviewer following either trail ran out of road. rescue.rs now cites Future work, which does document the assumption, and the README bullet states the upgrade path directly instead of forwarding to nothing. amt() rendered inf/NaN verbatim while its sibling pct() returned "n/a" for exactly that case, so a single report could print "Deposit inf SOL -> LTV 59.9%, buffer 25.0%" — a guarded percentage beside an unguarded amount. remedy::rank sizes every remedy by dividing a USD delta by an oracle price, so the unbounded case is reachable from real inputs rather than theoretical. Guarded to match pct, with a regression test over INFINITY/NEG_INFINITY/NAN that fails without it. Release wasm moves 564,783 -> 564,935 bytes; the evidence table's byte count and its pre-audit delta are updated to the rebuilt artifact.
…ing record, and fix the install path The wasip2 section claimed getrandom "ruled out solana-sdk and any crate that pulls it in transitively". That was stale. Re-measured per crate on Rust 1.96.1: solana-hash/pubkey/instruction/message and solana-sdk all compile for wasm32-wasip2, but every one above solana-hash puts getrandom back in the tree (safety invariant 7), and solana-transaction -- the crate that would actually serialize a transaction -- fails to compile outright, because it gates a wasm-bindgen browser module on target_arch = "wasm32" and wasip2 matches it. That is why the legacy serializer, shortvec encoder and base64 codec are hand-rolled; the README now shows the measurement rather than asserting the conclusion. Also: - Replace the documented `zeroclaw tool call kamino_guard` invocation, which is not a subcommand that exists, with the real deployment: `zeroclaw plugin install` plus a `zeroclaw cron add ... --agent ... --prompt` agent schedule. - Add an operating record: 190 successful `check` completions inside a real zeroclaw daemon between 2026-07-24 and 2026-08-03, counted from the plugin's own PluginOutcome::Success emissions, with the operating-day gaps stated rather than smoothed over. Drops the now-false "no host build was run" line. - Add a section on what a read-only monitor structurally cannot do: a remedy amount rather than a risk score, the transaction itself, landing during the congestion that caused the alert, and why none of that costs custody tier.
|
Converting this to a draft. This branch was opened on 20 July, before the bounty listing was updated with the guidance that registry PRs should not be opened during the bounty and that registry merges happen separately after judging. Parking it as a draft so it stays out of the review queue, rather than closing it — happy to mark it ready whenever that suits the maintainers. The bounty submission itself is a showcase post, not this PR; this is just where the code lives upstream. Latest push (
So the compile wall is real, but only on the transaction-serialization layer — which is the layer the listing says is covered. That is why this plugin hand-rolls the legacy serializer, shortvec encoder and base64 codec. My previous claim that Also in scope for #147: this plugin requests |
The previous wording said solana-transaction 'fails to compile', which is only true on default features. features = ["bincode"] (or blake3, which implies it) does compile -- a reader could have disproved the claim in one command. The finding is sharper stated precisely: the wasm-bindgen browser module is gated on target_arch = "wasm32" alone, so it is compiled for wasip2, where it calls two methods only the bincode feature provides. Enabling that feature builds but is still wrong here -- it pulls getrandom back in and emits a JS-binding shim into a component with no JavaScript host. Browser wasm is target_os = "unknown" and both WASI targets are target_os = "wasi", so the correct upstream gate is all(target_arch = "wasm32", target_os = "unknown").
The operating-record table was written from a partial read of the daemon log and had gone stale. Recounted from the full log at 2026-08-04T13:50:11Z: - successful check completions: 190 -> 242 (of 243 started) - operating days: seven -> eight (Jul 24-27, Aug 1-4) Adds what the earlier table omitted and what actually demonstrates the component works unattended: the tier split (141 WATCH -> 0 alerts, 101 WARN -> 101 alerts, no miss in either direction), delivery latency measured from the host's own tool_call_result records, the measured 20-minute cadence, the 4 d 16 h outage, and the six failure outcomes -- including the two runs lost to a deleted daemon binary. Every figure has the grep that checks it.
Track B: DeFi — custody tier T1. Builds unsigned transactions only: no key material ever enters the plugin,
sendTransactionappears nowhere insrc/, and every transaction ships with one zeroed signature slot for the user's own wallet to inspect and sign.What this adds
plugins/liquidation-guard— a Solana-native ZeroClaw plugin that watches Kamino Lend obligations and keeps agents' wallets out of the liquidation queue. One tool,kamino_guard, four actions:check— health assessment for one obligation: tiered warning (OK/WATCH/WARN/CRITICAL), liquidation-price forecast in both directions (collateral-fall and debt-rise, each denominated in its own asset's oracle price; for a pinned LST mint set the collateral-drop line alone is quoted at the underlying SOL level via stake rate, with its threshold and its quoted spot converted together), interest-drift attribution with borrow APY/utilization, and ranked remedies that restore to the WATCH boundary.portfolio— the same across every obligation of a wallet.rescue— builds a base64 unsignedrepay_obligation_liquidity_v2transaction (refresh_reserve×N repay-last → refresh_obligation → repay, 13 fixed accounts), capped by min(computed Δ, requested,max_repay_ui, wallet balance) with a truthful cap label.deposit— the other inbound remedy: a base64 unsigneddeposit_reserve_liquidity_and_obligation_collateral_v2transaction into the obligation's dominant collateral reserve, same fail-closed gate shape (max_deposit_uiabsent = disabled) and the same cap/label discipline.Both tx builders compose two opt-in, default-off knobs: priority fees (compute-budget pair, 900k CU limit, sized to cover klend's worst-case obligation rather than the golden tx alone — a limit pinned to the 6-reserve golden's 261,070 CU sat below the
min(ix_count x 200_000, 1_400_000)budget the same tx receives with no compute-budget ix at all, so enabling the fee could fail a many-reserve rescue that succeeded with it off) and durable nonce (advance ix pinned at index 0, stored nonce as the message blockhash, fail-closed 80-byte parse with an authority-must-equal-wallet gate). With both off, output is byte-identical to the captured goldens.Motivation: in one 48-hour stretch of February 2026, Kamino processed 55,649 liquidations seizing $19.36M from 30,030 wallets while their owners slept. This is the agent that doesn't sleep.
Custody model
The plugin cannot sign and cannot broadcast. No key material ever enters it;
sendTransactionappears nowhere insrc/(grep-verifiable). Every built transaction ships with one zeroed signature slot and is inspected and signed only in the user's own wallet.Transactions are inbound-only: repay and deposit both move funds from the user's wallet into the user's own position.
withdraw_obligation_collateral/borrow_obligation_liquidity/liquidate_obligationappear nowhere insrc/(also grep-verifiable) — those shapes are not gated at runtime, they are structurally unencodable.Threat model
api.kamino.financeplus the configured https-onlyrpc_url; closed read-only RPC method set (getGenesisHash,getLatestBlockhash,getTokenAccountBalance,getAccountInfo). NosimulateTransaction, nosendTransaction.api.kamino.finance, which serves mainnet and only mainnet, so a non-mainnetrpc_urlis a misconfiguration rather than a use case. Both tx paths route through oneresolve_blockhash, which issuesgetGenesisHashand refuses unless it matches the pinned mainnet-beta genesis — before a blockhash or nonce is ever fetched. An erroring or unreadable answer is a hard refusal, never a degrade to "assume mainnet".overflow-checks = truein the release profile. Wrapping money arithmetic traps instead of silently producing a wrong amount. Costs ~19.8 KB of wasm; worth it.rpc_url; deposit and rescue are each disabled until their cap key is explicitly configured.char::is_controlalone misses U+200B, U+202E, U+2028 and U+FEFF./?&#). Every numeric field must be finite and non-negative: Rust'sf64::from_straccepts"NaN"and"-1e400", and a negative borrow total drives the buffer above every threshold — reporting a maximally unhealthy position as OK. One unmappable row fails the whole list rather than being dropped, because every row of/users/{wallet}/obligationsis one of the user's own positions and dropping one turns "multiple obligations found; specifyobligation" into a confident verdict about a different position./obligationsis already wallet-scoped, so thestate.ownercheck only fires when the response disagrees with the request — but every transaction spends this wallet's tokens into that obligation, so ownership is verified in this source rather than trusted from the API.runnever panics, including on a hostileDateheader. Withoverflow-checks = truean overflow is an unrecoverable wasm trap, not an error, so every numeric field of the HTTPDateheader and every payload timestamp is range-checked before it reaches a multiplication.getrandomin the wasm32-wasip2 tree; no clock in the WIT world (the HTTPDateheader is the time source); hand-rolled legacy-tx serialization rather than a signing SDK.log-recordemission on every execute (start/complete/fail) — action name only, never args, wallet addresses, or payload content. Nothing on stdout.Hard requirements checklist
plugins/redact-text: pure core, thin#[cfg(target_family = "wasm")]shim,crate-type = ["cdylib", "rlib"]cargo test --locked, no wasm toolchain neededcargo build --locked --target wasm32-wasip2 --releaseclean — 564,783-byte component, exporting exactlyzeroclaw:plugin/plugin-info@0.1.0+zeroclaw:plugin/tool@0.1.0against the vendoredwit/v0(verified withwasm-tools component wit)cargo clippy -D warningsclean on native and wasm32-wasip2;cargo fmt --checkcleanlog-record; nothing on stdoutcapabilities = ["tool"], permissions onlyconfig_read+http_clientpython3 tools/build-registry.py --source-plugins plugins --check-metadata registry.jsongreen (pending unpublished source: liquidation-guard@0.2.0);registry.jsondeliberately untouched, since the publish workflow owns itLICENSE+ Cargo.tomllicensefield)Evidence
3oVjuGzMdAqqJy5poCzHUXqguwypgoM33JfZHFGkb5zb7gfPRtHXgFsgEG7iZP8WWerHttjYdnA8jamwgLbdDiacand deposit5wcNDh7HcUVEipGHk2xnzMigX1LwkPBPvsMJPvukUU3mxGkFTe1WYY3PMdHnufwCHkeDnUa1gECsYccEDuUDF7np.api.kamino.financepayloads parse cleanly through the real parsers, and four tx shapes were simulated viasimulateTransaction(sigVerify:false, curl, outside the plugin) against public mainnet RPC. Base rescue and fee-on rescue both execute through every refresh on real klend state and fail only at the terminal token transfer withCustom(1)insufficient funds — expected for an unsigned, unfunded tx; the compute-budget ixs succeed, 151,025 CU consumed (that run predates the raise of the CU ceiling to 900k, and sits far under either). The deposit tx does the same on its path. The nonce-composed tx is sanitized by a real node, which executesadvance_nonce_accountat index 0 against a real mainnet nonce account and demands the stored authority's signature — the exact condition the plugin's own parser refuses fail-closed up front, also demonstrated live against that same account throughguard::run.Rollback
Delete
plugins/liquidation-guard/. That is the whole change: 32 files, allunder that one directory,
+11,604 / -0. No host changes, nowit/changes,no edits to
tools/or the workflows, andregistry.jsonis untouched (thepublish workflow generates it). Nothing else in the repo references the
directory.
Limits (stated, not hidden)
Referrer-bearing obligations are refused outright. SOL-level LST quoting applies to the pinned mint set only (JitoSOL, mSOL, bSOL, jupSOL, INF, bnSOL); any other LST keeps a token-level quote rather than a guessed stake rate. Withdraw and borrow shapes are deliberately unencodable. Collateral cToken mints are assumed classic SPL Token (Kamino cTokens are never Token-2022 — documented assumption). Position data comes from Kamino's own price and loan endpoints; the plugin does not independently reconstruct obligation state from
getProgramAccounts.Questions for maintainers
check/portfolio/rescue/depositall read the same obligation, price, and reserve state and share the cap-and-label discipline. Happy to split if you would rather review the read-only path separately from the two transaction builders.registry.jsonis deliberately untouched here. If you would prefer the entry staged in the PR instead of generated at publish time, say the word.src/guard.rs::PINNED_LST_MINTS) rather than matched on payload symbols, on purpose. Adding a mint is a one-line change plus a fixture; tell me if you want more of them covered before merge.